Pro Entity Framework Core 2 for ASP.NET Core MVC 翻译

第 18 章 使用键

作者:Adam Freeman
翻译:陈广
日期:2019-5-20


为数据选择最合适的键将为数据模型的其余部分奠定基础。在前面的章节中,您已经看到了键在唯一标识对象中所起的作用,Entity Framework Core 用来选择属性作为键使用的约定,以及这些约定是如何被重写的。在本章中,我将描述 Entity Framework Core 提供的高级关键特性。与本书本部分中描述的许多特性一样,您不太可能需要每个使用 Entity Framework Core 的项目中的高级关键特性,但是当标准特性不能提供所需的功能时,对使用键的使用进行控制的能力对于这些不寻常的情况是重要的。

Entity Framework Core 无法生成对键进行实质性更改的迁移,这意味着本章中的大多数示例都要求重置数据库或删除早期迁移。这强调了尽早选择关键策略的重要性,以避免对数据库进行复杂的更改时,可能导致数据的丢失。表19-1为本章简述。

表 19-1:高级关键功能简述

问题 回答
它们是什么? 这些功能允许您更改键被创建和使用的方式。
它们有何用途? 并非所有应用程序都可以使用默认的主键功能,特别是在处理现有数据库时。
如何使用它们 这些功能在数据库的 context 类的 Fluent API 语句中使用。
是否有任何缺陷或限制? 这些功能需要仔细考虑,因为它很容易选择其值不能唯一标识对象的属性。
有没有其他选择? 这些功能是可选的,您可以使用第二部分所描述的基本功能

注意:许多高级功能只能使用 Fluent API,并且没有相应的特性。当有可用的特性时,我添加了注释,但是本章的重点 —— 以及本书本部分的其他章节 —— 坚定地强调了 Fluent API 的使用。

表 19-2 为本章摘要

问题 解决方案 清单
更改主键值的产生 使用 Fluent API 的键生成方法 14-16
使用原生键 使用IsUnique方法确保数据库中的值域不重复 17,18,25-27
使用附加属性标识对象 创建备用键
使用多个属性标识对象 创建复合键

准备本章

本章,我创建一个新的项目以演示 Entity Framework Core 支持的更为高级的功能。要创建项目,在 Visual Studio 的【文件】菜单中选择【新建】➤【项目】,使用【ASP.NET Core Web 应用程序】模板创建一个名为 AdvancedApp 的新项目,如图 19-1 所示。

图19-1 创建新应用程序

提示:如果您不想跟随构建示例项目的过程,可以从本书的源代码库下载所有所需的文件,这些文件可在 https://github.com/apress/pro-ef-core-2-for-asp.net-core-mvc 上找到。

单击【创建】按钮进入到下一个对话框。确保选择列表中的【ASP.NET Core 2.2】,并单击【空】模板,如图19-2所示。单击【创建】按钮关闭对话框并创建项目。

图19-2 配置项目

创建数据模型

本章数据模型将表现一个简单 HR 数据库中的雇员,只是为了从我在前面章节中使用的基于产品的例子中提供一些变化。我创建了一个名为 Models 的文件夹,并向其添加一个名为 Employee.cs 的类文件,该文件用于定义清单19-1中所示的类。

清单 19-1:Models 文件夹下的 Employee.cs 文件的内容

namespace AdvancedApp.Models
{
    public class Employee
    {
        public long Id { get; set; }
        public string SSN { get; set; }
        public string FirstName { get; set; }
        public string FamilyName { get; set; }
        public decimal Salary { get; set; }
    }
}

Employee类具有个人社会保险号、姓名和薪金的属性。真实的 HR 数据库需要更多的细节,但这已经足够开始了。

要创建数据库 context 类,我向 Models 文件夹添加了一个名为 AdvancedContext.cs 的类文件,并定义了清单19-2所示的类。

清单 19-2:Models 文件夹下的 AdvancedContext.cs 文件的内容

using Microsoft.EntityFrameworkCore;

namespace AdvancedApp.Models
{
    public class AdvancedContext : DbContext
    {
        public AdvancedContext(DbContextOptions<AdvancedContext> options)
            : base(options) { }
        public DbSet<Employee> Employees { get; set; }
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
        }
    }
}

context 类定义了一个DbSet属性,以提供对数据库中Employee对象的方便访问,并覆盖OnModelCreating方法,从而可以使用 Fluent API 配置数据库。目前不需要配置语句,因为我很高兴为Employee类使用默认约定。

创建控制器和视图

对于应用程序的 ASP.NET Core MVC 部分,首先创建 Controllers 文件夹,并向其中添加一个名为 HomeController.cs 的类文件,并定义了清单19-3所示的控制器。控制器定义了一个Index action,用于向用户显示数据,还定义了一个Update action 用于创建并更新对象。我在 20 章介绍了如何软删除对象(这意味着它们对用户隐藏,但仍在数据库中),并在第 22 间介绍如何真正删除对象。

清单 19-3:Controllers 文件夹下的 HomeController.cs 文件的内容

using AdvancedApp.Models;
using Microsoft.AspNetCore.Mvc;

namespace AdvancedApp.Controllers
{
    public class HomeController : Controller
    {
        private AdvancedContext context;
        public HomeController(AdvancedContext ctx) => context = ctx;

        public IActionResult Index()
        {
            return View(context.Employees);
        }

        public IActionResult Edit(long id)
        {
            return View(id == default(long)
                ? new Employee() : context.Employees.Find(id));
        }

        [HttpPost]
        public IActionResult Update(Employee employee)
        {
            if (employee.Id == default(long))
            {
                context.Add(employee);
            }
            else
            {
                context.Update(employee);
            }
            context.SaveChanges();
            return RedirectToAction(nameof(Index));
        }
    }
}

要为控制器提供相应的视图,我创建了 Views/Home 文件夹,并向其添加了一个名为 Index.cshtml 的文件,内容如清单 19-4 所示。此视图有一张表格显示从数据库读取的Employee对象的详细信息,以及允许创建及修改对象的按钮。

清单 19-4:Views/Home 文件夹下的 Index.cshtml 文件的内容

@model IEnumerable<Employee>
@{
    ViewData["Title"] = "Advanced Features";
    Layout = "_Layout";
}
<h3 class="bg-info p-2 text-center text-white">Employees</h3>
<table class="table table-sm table-striped">
    <thead>
        <tr>
            <th>Key</th>
            <th>SSN</th>
            <th>First Name</th>
            <th>Family Name</th>
            <th>Salary</th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        <tr class="placeholder"><td colspan="7" class="text-center">No Data</td></tr>
        @foreach (Employee e in Model)
        {
            <tr>
                <td>@e.Id</td>
                <td>@e.SSN</td>
                <td>@e.FirstName</td>
                <td>@e.FamilyName</td>
                <td>@e.Salary</td>
                <td class="text-right">
                    <a asp-action="Edit" asp-route-id="@e.Id"
                       class="btn btn-sm btn-primary">Edit</a>
                </td>
            </tr>
        }
    </tbody>
</table>
<div class="text-center">
    <a asp-action="Edit" class="btn btn-primary">Create</a>
</div>

为让用户可以创建或编辑Employee对象,我向 Views/Home 文件夹添加了一个名为 Edit.cshtml 的文件,其内容如清单19-5所示。

清单 19-5:Views/Home 文件夹下的 Edit.cshtml 文件的内容

@model Employee
@{
    ViewData["Title"] = "Advanced Features";
    Layout = "_Layout";
}

<h4 class="bg-info p-2 text-center text-white">
    Create/Edit
</h4>
<form asp-action="Update" method="post">
    <input type="hidden" asp-for="Id" />
    <div class="form-group">
        <label class="form-control-label" asp-for="SSN"></label>
        <input class="form-control" asp-for="SSN" />
    </div>
    <div class="form-group">
        <label class="form-control-label" asp-for="FirstName"></label>
        <input class="form-control" asp-for="FirstName" />
    </div>
    <div class="form-group">
        <label class="form-control-label" asp-for="FamilyName"></label>
        <input class="form-control" asp-for="FamilyName" />
    </div>
    <div class="form-group">
        <label class="form-control-label" asp-for="Salary"></label>
        <input class="form-control" asp-for="Salary" />
    </div>
    <div class="text-center">
        <button type="submit" class="btn btn-primary">Save</button>
        <a class="btn btn-secondary" asp-action="Index">Cancel</a>
    </div>
</form>

为给视图提供公共布局,我创建了 Views/Shared 文件夹并向其中添加了一个名为 _Layout.cshtml 的文件,内容如清单19-6所示。

清单 19-6:Views/Shared 文件夹下的 _Layout.cshtml 文件的内容

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>@ViewData["Title"]</title>
    <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
    <style>
        .placeholder { visibility: collapse }
        .placeholder:only-child { visibility: visible }
  </style>
</head>
<body>
    <div class="p-2">
        @RenderBody()
    </div>
</body>
</html>

布局包括一个包含 Bootstrap CSS 样式的文件的链接,以及一些自定义 CSS,当没有要显示的数据时,这些 CSS 将显示添加到 Index.cshtml 视图中placeholder类中的元素。

要启用标签助手并导入视图中使用的模型类的开发包,我向 Views 文件夹中添加了一个名为 _ViewImports.cshtml 的文件,其内容如清单19-7所示。

清单 19-7:Views 文件夹下的 _ViewImports.cshtml 文件的内容

@using AdvancedApp.Models
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

配置应用程序

译者注:本节所讲述的命令行工具包在 .NET Core 2.1 之后的版本默认情况已集成。如果使用的是此版本之后的 .NET Core,则忽略本小节内容。

为安装提供 Entity Framework Core 命令行工具的 NuGet 包,我在【解决方案资源管理器】中右键单击【AdvancedApp】项目,在弹出菜单中选择【编辑 AdvancedApp.csproj】,并添加清单19-8所示的元素。

清单 19-8:AdvancedApp 文件夹下的 AdvancedApp.csproj 文件,添加 NuGet 包

<Project Sdk="Microsoft.NET.Sdk.Web">

	<PropertyGroup>
		<TargetFramework>netcoreapp2.0</TargetFramework>
	</PropertyGroup>

	<ItemGroup>
		<Folder Include="wwwroot\" />
	</ItemGroup>

	<ItemGroup>
		<PackageReference Include="Microsoft.AspNetCore.All" Version="2.0.5" />
		<DotNetCliToolReference Include="Microsoft.EntityFrameworkCore.Tools.DotNet "Version="2.0.0" />
	</ItemGroup>

</Project>

为了为示例应用程序配置数据库的详细信息,我使用【ASP.NET 配置文件项】模板将名为 appsettings.json 的文件添加到 AdvancedApp 项目文件夹中,并添加了如清单19-9所示的配置设置。除了连接字符串之外,我还配置了日志记录系统,以便 Entity Framework Core 将显示它发送给数据库服务器的 SQL 查询和命令的详细信息。

译者注:在 Visual Studio 2019 中创建项目后,appsettings.json 文件默认已存在,不需重新创建。

清单 19-9:AdvancedApp 文件夹下的 appsettings.json 文件的内容

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=(localdb)\\MSSQLLocalDB;Database=AdvancedDb;MultipleActiveResultSets=true"
  },
  "Logging": {
    "LogLevel": {
      "Default": "None",
      "Microsoft.EntityFrameworkCore": "Information"
    }
  }
}

要启用 ASP.NET Core MVC 以及 Entity Framework Core 中间件,我向Startup类添加了清单19-10所示的配置语句。

清单 19-10:AdvancedApp 文件夹下的 Startup.cs 文件,配置中间件

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;
using AdvancedApp.Models;

namespace AdvancedApp
{
    public class Startup
    {
        public Startup(IConfiguration config) => Configuration = config;
        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc();
            string conString = Configuration["ConnectionStrings:DefaultConnection"];
            services.AddDbContext<AdvancedContext>(options =>
                options.UseSqlServer(conString));
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            app.UseDeveloperExceptionPage();
            app.UseStatusCodePages();
            app.UseStaticFiles();
            app.UseMvcWithDefaultRoute();
        }
    }
}

为安装 Bootstrap CSS 包,我在 ExistingDb 项目上单击右键,在弹出菜单中选择【添加】➤【客户端库】。在【添加客户端库】窗口的【库】栏中输入“twitter-bootstrap@4.3.1”;在【目标位置】栏中输入“wwwroot/lib/twitter-bootstrap/。”单击【安装】按钮,此时在【解决方案资源管理器】中可以看到生成了一个新的文件 libman.json,打开它,其内容如清单 17-15 所示。

清单 19-12:ExistingDb 文件夹下的 libman.json 文件的内容

{
  "version": "1.0",
  "defaultProvider": "cdnjs",
  "libraries": [
    {
      "library": "twitter-bootstrap@4.3.1",
      "destination": "wwwroot/lib/twitter-bootstrap/"
    }
  ]
}

另外,请查看是否新生成了【wwwroot】➤【lib】➤【twitter-bootstrap】文件夹,如果已经生成,则表明 BootStrap 包已经成功安装。

为了简化使用应用程序的过程,请编辑 Properties/launchSettings.json 文件,并更改其中包含的两个 URL,以便它们都指定端口 5000,如清单17-17所示。这是我将在 URL 中使用的端口,用于演示示例应用程序的不同特性。

清单 19-13:Properties 文件夹下手 launchSettings.json 文件,更改端口

{
  "iisSettings": {
    "windowsAuthentication": false, 
    "anonymousAuthentication": true, 
    "iisExpress": {
      "applicationUrl": "http://localhost:5000",
      "sslPort": 0
    }
  },
  "profiles": {
    "IIS Express": {
      "commandName": "IISExpress",
      "launchBrowser": true,
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    },
    "ExistingDb": {
      "commandName": "Project",
      "launchBrowser": true,
      "applicationUrl": "http://localhost:5000",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}

创建数据库并测试应用程序

在 AdvancedApp 项目文件夹下运行清单 19-14 所示的命令,创建并应用迁移以建立数据库并存储Employee对象。

清单 19-14:创建并应用数据库迁移

dotnet ef migrations add Initial
dotnet ef database update

我将在下一节删除并重建数据库,但在工作继续之前确保示例应用程序可以运行是非常重要的。使用dotnet run启动应用程序,并导航至 http://localhost:5000。当前数据库中没有数据,您将看到占位符内容。单击【Create】按钮,填写表单字段,然后单击【Save】按钮将一个新的Employee对象存储在数据库中,生成类似于图19-3所示的结果。

图19-3 运行示例应用程序

管理键的生成

在使用 SQL Server 时,可以使用两种策略来生成用于主键的值,这些值是使用表19-3中描述的方法配置的。

表 19-3:键生成方法

名称 描述
ForSqlServerUseIdentityColumns() 此方法为键生成选择标识策略
ForSqlServerUseSequenceHiLo() 此方法为键生成选择 Hi-Lo 策略

注意:并非所有的数据库服务器都支持这些键策略。请查阅您使用的数据库提供器包的文档以确定哪些策略是可用的。

理解标识策略

默认使用的是标识策略。当 Entity Framework Core 存储一个新对象,安依赖于数据库服务器来创建一个唯一主键值。这意味着存储一个对象需要两个操作,如果单击【Create】按钮、填写表单、单击【Save】按钮,检查应用程序生成的日志记录消息,您可以看到。第一个操作将新数据插入数据库,如下所示:

...
INSERT INTO [Employees] ([FamilyName], [FirstName], [SSN], [Salary])
VALUES (@p0, @p1, @p2, @p3);
...

Entity Framework Core 并未包含Id属性的值,因为它知道此值将由数据库服务器赋予(实际上,为UPDATE提供一个值将导致错误)。第二个操作查询数据库,以获得数据库在将新数据插入表时生成的主键,如下所示:

...
SELECT [Id]
FROM [Employees]
WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();
...

这种方法的优点是简单。使用数据库的应用程序不必相互协调以避免重复键,也不需要了解如何生成密钥。缺点是需要额外的查询才能获得键值。

提示:如果您不确定要遵循什么键策略,那么就使用标识策略,因为它是最容易使用的,也是最不可能引起问题的。

理解 Hi-Lo 键策略

Hi-Lo 是一种优化策略,允许 Entity Framework Core 创建主键值,而不是数据库服务器,同时仍然确保这些值是唯一的。需要做一些工作来了解该策略是如何工作的,因为 Entity Framework Core 迁移无法更改在早期迁移中创建的主键的策略。第一步是在 context 类中应用表19-3中的 Fluent API 方法来选择 Hi-Lo 策略,如清单19-15所示。

清单 19-15:Models 文件夹下的 AdvancedContext.cs 文件,选择键策略

using Microsoft.EntityFrameworkCore;

namespace AdvancedApp.Models
{
    public class AdvancedContext : DbContext
    {
        public AdvancedContext(DbContextOptions<AdvancedContext> options)
            : base(options) { }
        public DbSet<Employee> Employees { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Employee>()
                .Property(e => e.Id).ForSqlServerUseSequenceHiLo();
        }
    }
}

此策略是通过选择一个属性并表19-3所描述的ForSqlServerUseSequenceHiLo方法来应用的。为应用对键生成策略的更改,我需要移除现有的迁移,并创建一个新迁移,以便在单个迁移中创建数据模型,而无需更改标识策略。在 AdvancedApp 项目文件夹中运行清单19-16所示的命令以移除现有迁移并创建一个新的。

清单 19-16:重置迁移

dotnet ef migrations remove --force
dotnet ef migrations add HiLoStrategy

如果您检查 Migrations 文件夹下<timestamp>_ HiLoStrategy.cs文件中的Up方法,将看到已经设置了一个名为EntityFrameworkHiLoSequence的新序列,如下所示:

protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.CreateSequence(
        name: "EntityFrameworkHiLoSequence",
        incrementBy: 10);

    migrationBuilder.CreateTable(
        name: "Employees",
        columns: table => new
        {
            Id = table.Column<long>(nullable: false),
            SSN = table.Column<string>(nullable: true),
            FirstName = table.Column<string>(nullable: true),
            FamilyName = table.Column<string>(nullable: true),
            Salary = table.Column<decimal>(nullable: false)
        },
        constraints: table =>
        {
            table.PrimaryKey("PK_Employees", x => x.Id);
        });
}

新序列将被用于创建主键值,稍后我会解释。在 AdvanceApp 项目文件夹下运行清单19-17所示的命令,使用新迁移删除并重建数据库。

清单 19-17:重建数据库

dotnet ef database drop --force
dotnet ef database update

使用 Hi-Lo 策略

在 Hi-Lo 策略中,Entity Framework Core 负责根据从数据库服务器获得的初始种子值生成主键。当应用程序需要存储一个对象时,Entity Framework Core 将从EntityFrameworkHiLoSequence中获取下一个值,并将其视为它可以创建的 10 个主键值块中的第一个数字,而无需引用数据库服务器或与其他应用程序进行协调。例如,下一个序列值是 100,那么 Entity Framework Core 知道它可以使用主键 100、101、102等创建对象,直到109。主键块用完后,将读取序列中的下一个值。每个应用程序(或同一个应用程序的实例)遵循相同的过程来获得自己的键块,确保键值不重复。数据库服务器确保对序列值的每个请求得到不同的结果,确保没有分配重复的键块。

要查看此策略是如何工作的,启动应用程序,导航至 http://localhost:5000,完成创建和存储新Employee对象的过程。在应用程序的 ASP.NET Core MVC 部分的行为方式上不会有任何明显的差别,只有当您检查 Entity Framework Core 日志消息时,才能看到更改。

存储新对象时,Entity Framework Core 从序列中获取下一个值,如下所示:

...
SELECT NEXT VALUE FOR [EntityFrameworkHiLoSequence]
...

序列值是 Entity Framework Core 可以使用的包含 10 个主键的块的开始,不需要任何进一步的检查。这被称为键的“high”部分,它给出了 Hi-Lo 策略名称中的一部分。“low”部分来自于序列值递增后生成的键块,这些键包含在INSERT操作中,如下所示:

...
INSERT INTO [Employees] ([Id], [FamilyName], [FirstName], [SSN], [Salary])
VALUES (@p0, @p1, @p2, @p3, @p4);
...

和标识策略不同,Entity Framework Core 不需要查询数据库以决定主键值。每个键块在应用程序创建的 context 对象之间共享,因此 Entity Framework Core 只需在存储了10个新对象后查询序列中的下一个值。

警告:不要依赖 Entity Framework Core 来使用特定的键序列。如果切换到不同的数据库提供程序,Hi-Lo 策略的实现可能会改变或有不同实现。

这种策略的优点是,无需在每次插入操作之后通过查询来发现主键。缺点是所有使用数据库的应用程序都必须理解并遵循 Hi-Lo 策略才能工作。

理解 Hi-Lo 键枯竭

此策略可能会耗尽可能范围内的所有键,因为在重新启动应用程序时,块中未使用的键将“丢失”,因此必须为提供足够容量的主键选择数据类型。若要查看范围内的键是如何变为不可用的,请停止并重新启动应用程序,导航到 http://localhost:5000。单击【Crerate】按钮,向数据库存储另一个Employee对象。将从数据库中读取一个新的序列值,并将其用作键的“High”部分,产生如图19-4所示的结果。应用程序的上一个实例接从块中接收到的 2 至 10 范围内的键将不被使用。

图19-4 Hi-Lo 策略中的跳过键范围

使用原生键

有些数据类型有自己的原生键(natural keys),这意味着数据的某些方面可以唯一地标识对象。以Employee类为例,SSN属性可以成为原生键,在一个国家中,社会保障号可被依赖为唯一值。


理解替代键

即使Employee数据有一个原生键,我仍然在Employee类中添加了一个专用的主键属性,用于演示上一节中的密钥生成策略。这就是所谓的替代键(surrogate key),它的目的仅仅是识别一个对象;它与构成对象的其余值没有关系。很难进行影响主键的数据模型更改,正如您将在本章的示例中看到的那样,使用替代键有助于将未来更改对数据模型的影响降到最低。示例应用程序中的数据如果只需要支持一个国家,可能会很高兴地使用社会保险号码,但是为了支持其他国家而更改的话,可能需要删除或修改SSN属性。当没有使用替代键时,这要困难得多。


确保原生键的唯一值

即使我没有使用原生键来唯一标识Employee对象,确保存储在数据库的 SSN 属性没有重复值仍然非常重要。这将有助于防止用户输入错误的条目,并在需要更改数据模型时,有助于最小化问题。防止重复的最简单方法是为属性创建索引,如清单19-18所示。

清单 19-18:Models 文件夹下的 AdvancedContext.cs 文件,创建索引

using Microsoft.EntityFrameworkCore;

namespace AdvancedApp.Models
{
    public class AdvancedContext : DbContext
    {
        public AdvancedContext(DbContextOptions<AdvancedContext> options)
            : base(options) { }
        public DbSet<Employee> Employees { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Employee>()
                .Property(e => e.Id).ForSqlServerUseSequenceHiLo();

            modelBuilder.Entity<Employee>()
                .HasIndex(e => e.SSN).HasName("SSNIndex").IsUnique();
        }
    }
}

索引是通过使用Entity方法所选择的类来创建的,通过调用HasIndex方法来选择将用于创建索引的属性。HasName方法用于为索引指定一个名称,在使用原生键时,您还必须调用IsUnique方法来向数据库添加一个约束,以防止重复值。

注意:索引仅能使用 Fluent API 创建。特性不支持此功能。

清单为SSN属性设置了一个唯一索引,可以通过使用清单19-19所示的命令创建和应用迁移,将其添加到数据库中,该命令必须在 AdvancedApp 项目文件夹中运行。

清单 19-19:创建并应用迁移

dotnet ef migrations add UniqueIndex
dotnet ef database update

如果您检查被添加到 Migrations 文件夹下的<timestamp>_UniqueIndex.cs文件中的Up方法,将看到清单19-18中的 Fluent API 语句如何修改数据库以强制原生键的唯一性。

protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.AlterColumn<string>(
        name: "SSN",
        table: "Employees",
        nullable: true,
        oldClrType: typeof(string),
        oldNullable: true);

    migrationBuilder.CreateIndex(
        name: "SSNIndex",
        table: "Employees",
        column: "SSN",
        unique: true,
        filter: "[SSN] IS NOT NULL");
}

Up方法中的第一条语句更改SSN列的数据类型,使其具有固定的大小。第二个语句创建索引,将unique参数设置为true,从而禁止重复条目。启动应用程序,导航至 http://localhost:5000,尝试存储一个SSN值与现有条目相同的Employee对象;您将看到如图19-5所示的错误信息。

图19-5 为原生键强行唯一值

创建备用键

使用原生键值创建关系需要不同的方法。在这些情况下,需要一个备用键(alternate key),以确保唯一值,同时还配置数据库,以便可以通过附加键以及主键唯一标识对象。


理解备用键何时有用 在大多数应用程序中,您可以使用主键安全地创建关系,这是默认情况下 Entity Framework Core 所做的。如果您只需避免重复值,如上节所述,只需创建唯一索引而不是创建备用键。只有当您期望在将来将备用键值移动到另一个对象并希望无缝地传输现有关系时,才能在备用键上创建关系,这是大多数应用程序不必担心的事情。


为演示备用键的使用,我向 Models 文件夹添加了一个名为 SecondaryIdentity.cs 的文件,并使用它定义了清单19-20所示的类。

清单 19-20:Models 文件夹下的 SecondaryIdentity.cs 的内容

namespace AdvancedApp.Models
{
    public class SecondaryIdentity
    {
        public long Id { get; set; }
        public string Name { get; set; }
        public bool InActiveUse { get; set; }
        public string PrimarySSN { get; set; }
        public Employee PrimaryIdentity { get; set; }
    }
}

此类将表示员工所使用的另一个名称。SecondaryIdentity类定义了PrimaryIdentity属性,该属性构成与Employee类关系的一部分,PrimarySSN属性将被用于外键属性。为完全关系,我向Employee类添加了逆导航属性,如清单19-21所示。

清单 19-21:Models 文件夹下的 Employee.cs 文件,完全关系

namespace AdvancedApp.Models
{
    public class Employee
    {
        public long Id { get; set; }
        public string SSN { get; set; }
        public string FirstName { get; set; }
        public string FamilyName { get; set; }
        public decimal Salary { get; set; }
        public SecondaryIdentity OtherIdentity { get; set; }
    }
}

OtherIdentity属性返回一个SecondaryIdentity对象,它告知 Entity Framework Core 这是一对一关系。

默认情况下,Entity Framework Core 将在SecondaryIdentityEmployee类之间的关系中为外键列使用Employee类的主键。我在 context 类中添加了如清单19-22所示的 Fluent API 语句,以重写此约定并使用备用键。

注意:备用键仅能使用 Fluent API 创建。特性不支持此功能。

清单 19-22:Models 文件夹下的 AdvancedContext.cs 文件,使用备用键

using Microsoft.EntityFrameworkCore;

namespace AdvancedApp.Models
{
    public class AdvancedContext : DbContext
    {
        public AdvancedContext(DbContextOptions<AdvancedContext> options)
            : base(options) { }
        public DbSet<Employee> Employees { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Employee>()
                .Property(e => e.Id).ForSqlServerUseSequenceHiLo();

            //modelBuilder.Entity<Employee>()
            //    .HasIndex(e => e.SSN).HasName("SSNIndex").IsUnique();

            modelBuilder.Entity<Employee>().HasAlternateKey(e => e.SSN);

            modelBuilder.Entity<SecondaryIdentity>()
                .HasOne(s => s.PrimaryIdentity)
                .WithOne(e => e.OtherIdentity)
                .HasPrincipalKey<Employee>(e => e.SSN)
                .HasForeignKey<SecondaryIdentity>(s => s.PrimarySSN);
        }
    }
}

清单 19-22 中的第一条新语句使用HasAlternateKey方法创建了一个备用键,它和创建唯一索引具有相同的效果,但 Entity Framework Core 将允许使用所选属性创建关系。只有当您不打算立即建立关系时,此方法才需要将属性作为备用键进行准备,但我倾向于将其包括在内,只是为了使我的意图变得显而易见。

清单 19-22 中的第二条新语句在两个类间创建了关系。HasOneWithOne方法用于选择导航属性,HasPrincipalKey<T>HasForeignKey<T>方法用于选择备用键以及外键属性。

结果是SSN属性将被配置为备用键,在与SecondaryIdentity类关系中作为外键使用。在 AdvancedApp 项目中运行清单 19-23 所示的命令,创建并应用对数据库的更改。

清单 19-23:创建并应用数据库迁移

dotnet ef migrations add AlternateKey
dotnet ef database update

如果您检查添加进 Migration 文件夹下的<timestamp>_AlternateKey.cs文件中的Up方法,将在被创建用于存储SecondaryIdenetity对象的表中看到已被应用至PrimarySSN列的外键约束。

...
constraints: table =>
{
    table.PrimaryKey("PK_SecondaryIdentity", x => x.Id);
    table.ForeignKey(
        name: "FK_SecondaryIdentity_Employees_PrimarySSN",
        column: x => x.PrimarySSN,
        principalTable: "Employees",
        principalColumn: "SSN",
        onDelete: ReferentialAction.Restrict);
});
...

一旦定义了备用键,就可以像使用主键一样使用它来创建关系。为了完成对应用程序 ASP.NET Core MVC 部分的更改,我将清单19-24中所示的元素添加到 Edit.cshtml 视图中,以便用户可以创建或编辑SecondaryIdentity对象以及与其关联的Employee对象。

清单 19-24:Views/Home 文件夹下的 Edit.cshtml 文件,添加元素

@model Employee
@{
    ViewData["Title"] = "Advanced Features";
    Layout = "_Layout";
}

<h4 class="bg-info p-2 text-center text-white">
    Create/Edit
</h4>
<form asp-action="Update" method="post">
    <input type="hidden" asp-for="Id" />
    <div class="form-group">
        <label class="form-control-label" asp-for="SSN"></label>
        <input class="form-control" asp-for="SSN" />
    </div>
    <div class="form-group">
        <label class="form-control-label" asp-for="FirstName"></label>
        <input class="form-control" asp-for="FirstName" />
    </div>
    <div class="form-group">
        <label class="form-control-label" asp-for="FamilyName"></label>
        <input class="form-control" asp-for="FamilyName" />
    </div>
    <div class="form-group">
        <label class="form-control-label" asp-for="Salary"></label>
        <input class="form-control" asp-for="Salary" />
    </div>

    <input type="hidden" asp-for="OtherIdentity.Id" />
    <div class="form-group">
        <label class="form-control-label">Other Identity Name:</label>
        <input class="form-control" asp-for="OtherIdentity.Name" />
    </div>
    <div class="form-check">
        <label class="form-check-label">
            <input class="form-check-input" type="checkbox"
                   asp-for="OtherIdentity.InActiveUse" />
            In Active Use
        </label>
    </div>

    <div class="text-center">
        <button type="submit" class="btn btn-primary">Save</button>
        <a class="btn btn-secondary" asp-action="Index">Cancel</a>
    </div>
</form>

我还更改了 Home 控制器的Edit方法,以便跟随导航属性查询Employee对象,以包含关联数据并将其传递给视图,如清单 19-25 所示。

清单 19-25:Controllers 文件夹下的 HomeController.cs 文件,包含关联数据

using AdvancedApp.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Linq;

namespace AdvancedApp.Controllers
{
    public class HomeController : Controller
    {
        private AdvancedContext context;
        public HomeController(AdvancedContext ctx) => context = ctx;

        public IActionResult Index()
        {
            return View(context.Employees);
        }

        public IActionResult Edit(long id)
        {
            return View(id == default(long)
                ? new Employee() : context.Employees.Include(e => e.OtherIdentity)
                    .First(e => e.Id == id));
        }

        [HttpPost]
        public IActionResult Update(Employee employee)
        {
            if (employee.Id == default(long))
            {
                context.Add(employee);
            }
            else
            {
                context.Update(employee);
            }
            context.SaveChanges();
            return RedirectToAction(nameof(Index));
        }
    }
}

Edit方法的查询使用了Include方法来跟随导航属性,以及First方法来查找用户指定的Id值的对象。要检查备用键的工作状况,使用dotnet run启动应用程序,并导航至 http://localhost:5000,创建或编辑一个Employee对象。通过添加新的关系(以及它使用的备用键),您可以提供第二标识的详细信息,如图19-6所示。

图19-6 在关系中使用备用键作为外键

提示:不要对已经存储于数据库的对象的SSNFirstNameFamilyName属性作任何更改。无法更改构成键的属性,除非数据库被专门配置为允许更改

使用原生键作为主键

如果您不想使用替代键 —— 通常是因为您有信心不会对数据模型进行更改 —— 您可以选择要用作主键的属性,并负责生成唯一值,尽管这并不是要轻率的决定,因为您要负责确保每个值都是唯一的。原生键可能是混乱的,不能总是依赖于像在项目设计阶段可能假设的那样独特。为了演示使用原生键作为主键,我使用 Fluent API 语句重新配置了数据模型,告诉 Entity Framework Core 忽略现有的主键属性并使用 SSN 属性,如清单19-26所示。

注意:您可以通过使用Key特性来选择任意属性作为主键。此示例的其余部分是相同的,包括处理 用户/应用程序 负责生成的密钥所需的更改。

清单 19-26:Models 文件夹下的 AdvancedContext.cs 文件,使用原生键

using Microsoft.EntityFrameworkCore;

namespace AdvancedApp.Models
{
    public class AdvancedContext : DbContext
    {
        public AdvancedContext(DbContextOptions<AdvancedContext> options)
        : base(options) { }
        public DbSet<Employee> Employees { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Employee>().Ignore(e => e.Id);
            modelBuilder.Entity<Employee>().HasKey(e => e.SSN);

            modelBuilder.Entity<SecondaryIdentity>()
                .HasOne(s => s.PrimaryIdentity)
                .WithOne(e => e.OtherIdentity)
                .HasPrincipalKey<Employee>(e => e.SSN)
                .HasForeignKey<SecondaryIdentity>(s => s.PrimarySSN);
        }
    }
}

我使用了Ignore方法从数据模型中排除Id属性,用HasKey方法选择SSN属性作为主键。配置EmployeeSecondaryIdentity之间关系的语句不需要更改,尽管可以删除对HasPrincipalKey方法的调用,因为在默认情况下,Entity Framework Core 将在此关系中使用SSN属性,因为它是主键。

在 AdvancedApp 项目文件夹下运行清单19-27所示的命令,创建并应用更改主键的迁移。

清单 19-27:重置并更新数据库

dotnet ef migrations remove --force
dotnet ef migrations add NaturalPrimaryKey
dotnet ef database drop --force
dotnet ef database update

Entity Framework Core 难以进行涉及键的更改,如果试图添加迁移更改,主键将无法工作,因为迁移将尝试删除为其上创建的关系所使用的备用键设置的约束。为了解决这个问题,我删除了设置备用键的迁移,创建了选择SSN属性作为主键的迁移,然后重新创建数据库。

为了对应用程序的 ASP.NET Core MVC 部分进行修改,我更新了控制器,以便它使用SSN属性作为主键,如清单19-28所示。

清单 19-28:Controllers 文件夹下的 HomeController.cs 文件,使用新主键

using AdvancedApp.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Linq;

namespace AdvancedApp.Controllers
{
    public class HomeController : Controller
    {
        private AdvancedContext context;
        public HomeController(AdvancedContext ctx) => context = ctx;

        public IActionResult Index()
        {
            return View(context.Employees);
        }

        public IActionResult Edit(string SSN)
        {
            return View(string.IsNullOrWhiteSpace(SSN)
                ? new Employee() : context.Employees.Include(e => e.OtherIdentity)
                    .First(e => e.SSN == SSN));
        }

        [HttpPost]
        public IActionResult Update(Employee employee)
        {
            if (context.Employees.Count(e => e.SSN == employee.SSN) == 0)
            {
                context.Add(employee);
            }
            else
            {
                context.Update(employee);
            }
            context.SaveChanges();
            return RedirectToAction(nameof(Index));
        }
    }
}

最重要的更改是Update方法。当我依赖于数据库服务器来生成键值时,可以通过检查键类型的默认值来确定请求是更新还是创建操作。由于用户负责提供键值,所以我无法这样做,因此我已经查询了数据库,以检查是否存在包含在请求中的键值的现有对象,这是我使用LINQ Count方法执行的。

使用dotnet run启动应用程序,导航至 http://localhost:5000,单击【Create】按钮,在数据库中存储一个新的Employee对象。如果检查应用程序生成的日志消息,将看到Update方法中的查询将导致以下操作:

...
SELECT COUNT(*)
FROM [Employees] AS [e]
WHERE [e].[SSN] = @__employee_SSN_0
...

此检查允许我在使用 MVC 模型绑定器创建的对象执行更新时,确定用户指定的键是否已经在数据库中,而无需加载数据并跳过 Entity Framework Core 数据缓存。

创建复合键

复合键通过组合来自数据库表中两个或多个列的值或实体类中的属性来唯一标识对象。在使用标识或 Hi-Lo 策略生成键值时,您不需要创建复合键,因为它们总是唯一的,但是当使用只有与另一个值相结合的原生键时,复合键才会有用。在美国,社会保险号码通常被视为独一无二的数字,但一些研究估计,由于混淆、错误和欺诈的综合作用,有4000万个数字被不止一个人使用。目前,如果尝试创建一个使用已存储在数据库中的SSN值的Employee对象,则示例应用程序将生成一个异常,但在本节中,我将通过组合属性来识别对象,从而放宽有关SSN唯一性的规则,如清单19-29所示。

注意:复合键仅可以使用 Fluent API 创建。没有特性支持此功能。

清单 19-29:Models 文件夹下的 AdvancedContext.cs 文件,创建复合键

using Microsoft.EntityFrameworkCore;

namespace AdvancedApp.Models
{
    public class AdvancedContext : DbContext
    {
        public AdvancedContext(DbContextOptions<AdvancedContext> options)
            : base(options) { }

        public DbSet<Employee> Employees { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Employee>().Ignore(e => e.Id);
            modelBuilder.Entity<Employee>()
                .HasKey(e => new { e.SSN, e.FirstName, e.FamilyName });
            modelBuilder.Entity<SecondaryIdentity>()
                .HasOne(s => s.PrimaryIdentity)
                .WithOne(e => e.OtherIdentity)
                .HasPrincipalKey<Employee>(e => new {
                    e.SSN,
                    e.FirstName,
                    e.FamilyName
                })
                .HasForeignKey<SecondaryIdentity>(s => new {
                    s.PrimarySSN,
                    s.PrimaryFirstName,
                    s.PrimaryFamilyName
                });
        }
    }
}

复合键是通过一个对象来创建的,该对象应该选择键中使用到的属性,即本例中的SSNFirstNameFamilyName属性。只要每个对象具有唯一的组合值,数据库中的每个属性都可以有重复的值。构成键的属性也必须用于创建关系,您可以看到,在HasPrincipalKey方法中使用的 lambda 表达式中反映了这些属性,该方法配置了与SecondaryIdentity类的关系的一端。

...
.HasPrincipalKey<Employee>(e => new { e.SSN, e.FirstName, e.FamilyName })
...

对于HasForeignKey方法,我指定了一些额外的外键属性,用于使用复合键跟踪关联的Employee对象,我在清单19-30中的SecondaryIdentity类上定义了这些属性。

清单 19-30:Models 文件夹下的 SecondaryIdentity.cs 文件,添加外键属性

namespace AdvancedApp.Models
{
    public class SecondaryIdentity
    {
        public long Id { get; set; }
        public string Name { get; set; }
        public bool InActiveUse { get; set; }
        public string PrimarySSN { get; set; }
        public string PrimaryFamilyName { get; set; }
        public string PrimaryFirstName { get; set; }
        public Employee PrimaryIdentity { get; set; }
    }
}

在 AdvancedApp 项目文件夹中运行清单19-31所示命令,创建新的迁移并应用到数据库。Entity Framework Core 试图进行主键所需的更改,因此这些命令从上一节中删除迁移,添加新的迁移,然后重新创建数据库。

清单 19-31:创建和应用数据库迁移

dotnet ef migrations remove --force
dotnet ef migrations add CompositeKey
dotnet ef database drop --force
dotnet ef database update

如果您检查 Migrations 文件夹下的<timestamp>_CompositeKey.cs文件中的Up方法,将找到一条语句,该语句将使用清单19-29中选择的属性组合来配置 Employees 表的主键。

...
migrationBuilder.AddPrimaryKey(
    name: "PK_Employees",
    table: "Employees",
    columns: new[] { "SSN", "FirstName", "FamilyName" });
...

创建复合键时,更改必须贯穿应用程序的其余部分。在清单19-32中,我更新了控制器,以便它在查询数据库时使用所有的键属性。

清单 19-32:Controllers 文件夹下的 HomeController.cs 文件,使用复合键

using AdvancedApp.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Linq;

namespace AdvancedApp.Controllers
{
    public class HomeController : Controller
    {
        private AdvancedContext context;
        public HomeController(AdvancedContext ctx) => context = ctx;

        public IActionResult Index()
        {
            return View(context.Employees);
        }

        public IActionResult Edit(string SSN, string firstName, string familyName)
        {
            return View(string.IsNullOrWhiteSpace(SSN)
                ? new Employee() : context.Employees.Include(e => e.OtherIdentity)
                .First(e => e.SSN == SSN
                    && e.FirstName == firstName
                    && e.FamilyName == familyName));
        }

        [HttpPost]
        public IActionResult Update(Employee employee)
        {
            if (context.Employees.Count(e => e.SSN == employee.SSN
                && e.FirstName == employee.FirstName
                && e.FamilyName == employee.FamilyName) == 0)
            {
                context.Add(employee);
            }
            else
            {
                context.Update(employee);
            }
            context.SaveChanges();
            return RedirectToAction(nameof(Index));
        }
    }
}

如果没有这些更改,控制器将不会使用完整的主键查询数据库,这将导致一些奇怪的结果,要么选择错误的对象进行编辑,要么如果它有一个已在数据库中的SSN值,则无法插入一个新对象。

提示:一些方法,如Find,接受一系列用于查询数据库的键值。使用这些方法时,必须以与在 context 类中定义复合键相同的顺序提供值。对于示例应用程序,这意味着按照这个顺序使用SSNFirstNameFamilyName属性的值进行查询,因为我就是这样定义复合键的。

最后的更改是添加一些附加属性,这些属性选择要在 Index.cshtml 文件中编辑的对象,如清单19-33所示,以便控制器定义的编辑方法接收所有主键值。

清单 19-33:Views/Home 文件夹下的 Index.cshtml 文件,使用复合键

@model IEnumerable<Employee>
@{
    ViewData["Title"] = "Advanced Features";
    Layout = "_Layout";
}
<h3 class="bg-info p-2 text-center text-white">Employees</h3>
<table class="table table-sm table-striped">
    <thead>
        <tr>
            <th>Key</th>
            <th>SSN</th>
            <th>First Name</th>
            <th>Family Name</th>
            <th>Salary</th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        <tr class="placeholder"><td colspan="7" class="text-center">No Data</td></tr>
        @foreach (Employee e in Model)
        {
            <tr>
                <td>@e.Id</td>
                <td>@e.SSN</td>
                <td>@e.FirstName</td>
                <td>@e.FamilyName</td>
                <td>@e.Salary</td>
                <td class="text-right">
                    <a asp-action="Edit" asp-route-ssn="@e.SSN"
                       asp-route-firstname="@e.FirstName"
                       asp-route-familyname="@e.FamilyName"
                       class="btn btn-sm btn-primary">Edit</a>
                </td>
            </tr>
        }
    </tbody>
</table>
<div class="text-center">
    <a asp-action="Edit" class="btn btn-primary">Create</a>
</div>

asp-route-特性提供用户编辑员工时标识对象所需的复合键值。要确保复合键正常工作,请启动应用程序,导航到 http://localhost:5000,并使用表19-4中所示的数据值创建新的Employee对象。

表 19-4:用于检查复合键的数据

SSN FirstName FamilyName Salary Other Name In Active Use
420-39-1864 Bob Smith 100000 Robert Checked
420-39-1864 Alice Jones 200000 Allie Checked
420-39-1864 Bob Smith 150000 Bobby Unchecked

表中的所有三行的SSN属性值都是相同的,但只有在尝试创建第三个对象时才会看到异常,该对象对主键中使用的三个属性具有相同的组合值。这在应用程序显示的异常消息中可以看到,如图19-7所示。

图19-7 尝试创建相同的复合键

错误消息包括重复键的详细信息,这说明了用于标识对象的主键属性的组合,如下所示:

...
The duplicate key value is (420-39-1864, Bob, Smith).
...

当值的组合形成键时,单个属性可以具有重复值,如本例所示。

总结

在本章中,我演示了用于键的高级 Entity Framework Core 功能。我解释了标识和 Hi-Lo 键生成策略,以及如何以不同的方式使用原生键:确保唯一值,使用它们来创建关系,以及作为主键。在下一章中,我将描述查询的高级功能。

;

© 2018 - IOT小分队文章发布系统 v0.3